在完成了对智能合约安全基础(重入、溢出、访问控制)的理解之后,我们终于要将这些知识融入一个完整的应用级场景——去中心化投票系统。投票是区块链最早被看好的落地场景之一:它天然需要不可篡改的记票本与透明可查的计票规则,而这正是智能合约的核心优势。
本章将带领你经历一次从业务需求拆解到合约代码落地再到Hardhat 工程化部署的完整闭环。你将亲手编写一个支持管理员注册候选人、授权选民身份、防重入投票、实时计票的 Vote.sol 合约,并借助 Hardhat 工具链在本地网络完成部署与交互验证。后续章节会在此基础上搭建 React 前端,形成完整的 DApp。
17.1 需求分析与智能合约设计
17.1.1 投票场景的业务需求拆解
一个可信的链上投票系统,至少需要满足以下五条业务规则:
- 管理员创建选举:由可信的管理员初始化选举主题,并注册候选人名单。
- 选民身份注册:投票前,管理员或授权机构将合格选民的地址录入白名单,防止无关地址参与。
- 一人一票制:每个被授权的选民在整个选举周期中只能投出一张选票,且不可撤回或修改。
- 结果实时可见:每位候选人的得票数对全网络公开透明,任何人都可以验证计票结果。
- 生命周期管理:选举应具有明确的开始与结束状态(本章先做简化版,后续扩展为时间锁控制)。
这些需求看似朴素,但在去信任环境中,每一条都需要用代码强制约束,因为链上不存在现实中的「检票员」。
17.1.2 威胁模型与安全假设
在设计合约之前,我们先建立威胁模型(Threat Model),明确「敌人会怎么做」。
威胁一:重复投票
- 场景:选民 A 在投完票后再次调用
vote()函数。 - 危害:攻击者或恶意节点可通过大量重复投票人为放大某候选人的得票数,导致结果失真。
- 防御:在合约中维护
mapping(address => bool) voted,在投票函数入口处通过require(!voted[msg.sender])强制回滚。
威胁二:非授权投票
- 场景:地址 B 从未被管理员授权,却直接调用
vote()。 - 危害:破坏身份验证机制,使投票结果失去代表性。
- 防御:维护
mapping(address => bool) authorizedVoters白名单,投票前双重校验——既查身份又查是否已投票。
威胁三:管理员作恶
- 场景:管理员在选举进行中擅自修改候选人名单、取消已投选票,甚至自己伪造投票。
- 危害:中心化风险违背区块链「去信任」的设计初衷。如果管理员是一把没有制约的私钥,整个系统就变成「链上 Excel 表格」。
- 缓解思路:生产环境应引入多签钱包(如 Gnosis Safe)管理
owner权限,或将关键操作交由 DAO 治理合约投票决定。本章为教学清晰,先以 OpenZeppelin 的Ownable做简化实现,后续章节再做扩展。
17.1.3 合约架构设计
基于以上需求与威胁分析,合约由三个核心模块构成:
- Election 主合约:管理选举元数据(名称、状态、候选人列表、选民注册表),作为整个系统的调度中心。
- Candidate 数据结构:
struct Candidate { uint id; string name; uint voteCount; }。使用id作为键值索引,既方便前端快速查询,也节省遍历候选人的 Gas。 - VoterRegistry 身份映射:
mapping(address => bool) authorizedVoters记录授权白名单,mapping(address => bool) voted记录已投票状态。
下图展示了投票 DApp 的权限模型与核心数据流:
flowchart LR
A[管理员] -->|创建选举 / 注册候选| C[智能合约 Vote.sol]
A -->|授权选民地址| C
B[授权选民] -->|vote(candidateId)| C
C -->|记录投票状态| D[链上存储]
D -->|实时查询得票| E[任何人]
C -->|emit Voted| F[前端 / 监听器]
如图,管理员负责初始化与授权,选民只能执行一次受限的投票操作,而合约状态对所有参与者公开透明。这样的设计将「信任」从某个中心化机构转移到了可审计的代码逻辑上。
本节要点
- 投票合约的核心需求是「身份认证、一人一票、公开计票」。
- 威胁模型告诉我们:防御重复投票、非授权投票与中心化管理员作恶是设计重点。
- 合约架构围绕
Election(调度)、Candidate(数据)与VoterRegistry(身份)三层展开。
17.2 编写投票合约:候选人注册、投票与防重入
17.2.1 核心状态变量与数据结构
合约需要以下关键状态:
| 变量 | 类型 | 用途 |
|---|---|---|
candidatesCount | uint | 候选人总数,作为自增 ID 分配器 |
candidates[id] | mapping(uint => Candidate) | 按 ID 索引候选人信息 |
voted[addr] | mapping(address => bool) | 记录某地址是否已投票 |
authorizedVoters[addr] | mapping(address => bool) | 授权白名单 |
owner | address | 管理员地址,继承自 OpenZeppelin 的 Ownable |
其中 Candidate 结构体定义为:
struct Candidate {
uint id;
string name;
uint voteCount;
}17.2.2 访问控制:onlyOwner 与 OpenZeppelin 继承
许多初学者会手写一个 modifier onlyOwner,但更好的方案是直接继承 OpenZeppelin 的 Ownable.sol:
import "@openzeppelin/contracts/access/Ownable.sol";
contract Vote is Ownable {
// 自动生成 owner 与 onlyOwner 修饰符
}这样做的好处有三重:经过社区审计的代码更安全;未来迁移到 AccessControl 做细粒度角色管理时路径清晰;代码语义对阅读者更友好。
17.2.3 投票函数完整流程设计
投票函数 vote(uint _candidateId) 必须按如下严格顺序执行——这一顺序是防御重入攻击(Reentrancy)的关键:
- 资格审查:
require(authorizedVoters[msg.sender], "Not authorized"); - 防重复投票:
require(!voted[msg.sender], "Already voted"); - 标记已投票:
voted[msg.sender] = true;——先更新状态 - 计票增加:
candidates[_candidateId].voteCount++; - 触发事件:
emit Voted(msg.sender, _candidateId);
⚠️ 为什么是「先改状态、再发事件、最后外部调用」?
虽然本章的投票函数不涉及向用户转账,但如果未来扩展为「投票即获得代币奖励」,函数末尾的
transfer或call就可能被攻击者利用重入漏洞回调vote()。若状态更新放在外部调用之后,攻击者在回调时仍满足!voted[msg.sender],从而可以无限循环投票。将状态更新置于最前面,是 Solidity 安全编程的黄金铁律——Checks-Effects-Interactions 模式。
17.2.4 管理员函数与查询接口
- 构造函数:
constructor(string memory _electionName) Ownable(msg.sender),初始化选举名称并设定部署者为管理员。 - 注册候选人:
addCandidate(string memory _name),仅onlyOwner可调用,内部将candidatesCount自增并写入新候选信息。 - 授权选民:
authorizeVoter(address _voter),将地址加入白名单。 - 查询候选人:
getCandidate(uint _id) public view returns (Candidate memory),供前端读取数据。
17.2.5 完整投票合约代码(完整可运行)
以下是一段基于 Solidity 0.8.19、完整继承 OpenZeppelin Ownable 的投票合约,附带 natspec 风格注释,可直接放入 contracts/Vote.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title 去中心化投票合约
/// @author 教程作者
/// @notice 支持管理员注册候选人、授权选民,并确保每人只能投一票
/// @dev 基于 OpenZeppelin Ownable 做访问控制
contract Vote is Ownable {
/// @notice 候选人数据结构
struct Candidate {
uint id;
string name;
uint voteCount;
}
/// @notice 选举名称
string public electionName;
/// @notice 候选人总数,自动递增作为 ID 分配器
uint public candidatesCount;
/// @notice 候选人索引映射,id => Candidate
mapping(uint => Candidate) public candidates;
/// @notice 记录某地址是否已投票
mapping(address => bool) public voted;
/// @notice 授权选民白名单
mapping(address => bool) public authorizedVoters;
/// @notice 投票事件,用于前端实时监听
/// @param voter 投票者地址
/// @param candidateId 所投候选人 ID
event Voted(address indexed voter, uint indexed candidateId);
/// @notice 注册候选人事件
/// @param candidateId 候选人 ID
/// @param name 候选人名称
event CandidateAdded(uint indexed candidateId, string name);
/// @notice 选民授权事件
/// @param voter 被授权地址
event VoterAuthorized(address indexed voter);
/// @param _electionName 选举主题名称
constructor(string memory _electionName) Ownable(msg.sender) {
electionName = _electionName;
}
/// @notice 管理员注册新候选人
/// @param _name 候选人名称
function addCandidate(string memory _name) public onlyOwner {
candidatesCount++;
candidates[candidatesCount] = Candidate(candidatesCount, _name, 0);
emit CandidateAdded(candidatesCount, _name);
}
/// @notice 管理员将地址加入授权选民白名单
/// @param _voter 待授权的以太坊地址
function authorizeVoter(address _voter) public onlyOwner {
authorizedVoters[_voter] = true;
emit VoterAuthorized(_voter);
}
/// @notice 授权选民为指定候选人投票,每人只能投一次
/// @param _candidateId 候选人 ID(1-based)
function vote(uint _candidateId) public {
// Step 1: 身份校验
require(authorizedVoters[msg.sender], "Vote: caller is not an authorized voter");
// Step 2: 防重复投票
require(!voted[msg.sender], "Vote: already voted");
// Step 3: 候选人存在性校验
require(_candidateId > 0 && _candidateId <= candidatesCount, "Vote: invalid candidate ID");
// Step 4: 先更新状态(Checks-Effects)
voted[msg.sender] = true;
// Step 5: 计票增加
candidates[_candidateId].voteCount++;
// Step 6: 触发事件(Interactions 的最后一步)
emit Voted(msg.sender, _candidateId);
}
/// @notice 按 ID 查询候选人信息
/// @param _id 候选人 ID
/// @return 候选人结构体
function getCandidate(uint _id) public view returns (Candidate memory) {
require(_id > 0 && _id <= candidatesCount, "Vote: invalid candidate ID");
return candidates[_id];
}
}这段代码遵循了安全编码的三大原则:
- 单一入口约束:所有状态变更只能由
vote()函数一次触发,不存在旁路修改票数的后门。 - 先校验后修改:
require语句在前,确保非法请求在 Gas 消耗最小的阶段被回退。 - 事件驱动透明:每一次投票都会触发
Voted事件,前端可以通过ethers.js的contract.on("Voted", ...)实时更新计票面板。
本节要点
- 核心状态由
candidates、voted、authorizedVoters三组映射共同维护。- 投票函数必须遵循 Checks → Effects → Interactions 顺序,这是防重入的黄金铁律。
- OpenZeppelin
Ownable让访问控制更简洁、更安全、更可维护。natspec注释不仅是文档,还能被 Etherscan 与开发者工具自动解析。
17.3 Hardhat 工程化:编译、部署与脚本编写
17.3.1 项目初始化与依赖安装
将合约代码落地为可运行、可测试、可部署的工程,需要一套完整的开发工具链。本章选用 Hardhat——它内置了以太网模拟节点、任务系统与插件生态,是目前以太坊开发者最主流的工程化框架。
创建并初始化项目的命令如下:
mkdir voting-dapp && cd voting-dapp
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init # 选择「Create a TypeScript project」或 JavaScript 项目
npm install @openzeppelin/contracts初始化后的项目目录结构如下:
voting-dapp/
├── contracts/
│ └── Vote.sol # 将 17.2.5 的合约代码放入此处
├── scripts/
│ └── deploy.js # 部署脚本
├── test/
│ └── Vote.test.js # 单元测试(后续章节展开)
├── hardhat.config.js # 编译器与网络配置
├── package.json
└── node_modules/下图展示了 Hardhat 项目内各目录的职责与工作流程:
flowchart TD
A[contracts/ 合约源码] -->|hardhat compile| B[编译产物 artifacts/]
B --> C[scripts/ 部署脚本]
C -->|npx hardhat run| D[Hardhat Network 本地节点]
E[test/ 单元测试] -->|npx hardhat test| D
D -->|实时反馈| F[开发者控制台]
contracts/ 保存 .sol 源码,scripts/ 负责部署与初始化,test/ 做断言验证,而 Hardhat Network 为这一切提供极速的本地执行环境,不需要等待真实区块确认。
17.3.2 配置 hardhat.config.js
为了让 Hardhat 正确编译 0.8.19 版本的 Solidity 代码,我们需要在配置文件中指定编译器版本,并开启优化器以降低部署 Gas:
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: {
version: "0.8.19",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
// 默认网络为内置的 Hardhat Network,无需额外配置即可测试
// 后续部署到 Sepolia 等测试网时,可在此添加 network 配置
};optimizer 的作用是压缩合约字节码体积并减少运行时 Gas。runs: 200 表示优化器重排代码以假设函数会被调用约 200 次,这是兼顾部署成本与执行成本的常用参数。
17.3.3 编写部署脚本
部署脚本 scripts/deploy.js 的职责不仅是「部署一份合约」,更包括预填充初始化数据——注册候选人与授权测试选民,让后续手动交互时可以直接投票,而不必重复打字。
// scripts/deploy.js
const hre = require("hardhat");
async function main() {
// 获取部署者钱包地址
const [deployer, voter1, voter2] = await hre.ethers.getSigners();
console.log("部署者地址:", deployer.address);
// 1. 编译并获取合约工厂
const VoteFactory = await hre.ethers.getContractFactory("Vote");
// 2. 部署合约,传入构造函数参数
const voteContract = await VoteFactory.deploy("2024 社区治理选举");
// 3. 等待部署上链
await voteContract.waitForDeployment();
console.log("投票合约已部署至:", await voteContract.getAddress());
// 4. 管理员操作:注册候选人
await (await voteContract.addCandidate("Alice - 建设更多公共节点")).wait();
await (await voteContract.addCandidate("Bob - 优化 Gas 费分配")).wait();
await (await voteContract.addCandidate("Charlie - 引入 DAO 治理")).wait();
console.log("已注册 3 名候选人");
// 5. 管理员操作:授权测试选民
await (await voteContract.authorizeVoter(voter1.address)).wait();
await (await voteContract.authorizeVoter(voter2.address)).wait();
console.log("已授权测试选民:", voter1.address, voter2.address);
// 6. 初始状态查询
const c1 = await voteContract.getCandidate(1);
console.log(`候选人 #1: {c1.voteCount}`);
}
// 优雅处理异步异常
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});执行部署:
npx hardhat run scripts/deploy.js --network hardhat控制台应依次输出部署地址、候选人注册信息与测试选民地址。
17.3.4 使用 Hardhat Console 本地交互验证
脚本部署后,接下来进入交互式验证阶段。Hardhat 提供了 console 子命令,允许开发者在已部署合约上逐条执行函数调用,非常适合教学与调试。
首先,在另一个终端启动一个长期运行的本地节点(模拟真实区块链,MetaMask 也能够连接):
npx hardhat node然后打开 Hardhat Console 并连接到该本地节点,加载已部署的合约实例:
npx hardhat console --network localhost在 > 提示符下,你可以执行以下操作来验证业务逻辑:
// 加载已部署的合约(替换为 deploy.js 输出的实际地址)
const Vote = await ethers.getContractFactory("Vote");
const vote = await Vote.attach("0x5FbDB2315678afecb367f032d93F642f64180aa3");
// 获取可用签名者
const [owner, voter1, voter2, stranger] = await ethers.getSigners();
// 查询候选人信息
await vote.getCandidate(1);
// 返回: [ 1n, 'Alice - 建设更多公共节点', 0n ]
// 以 voter1 身份投票
await vote.connect(voter1).vote(1);
// 交易确认后,voter1 成功投票给候选人 1
// 再次查询票数
await vote.getCandidate(1);
// 返回: [ 1n, 'Alice - 建设更多公共节点', 1n ]
// 尝试重复投票(应被回滚)
await vote.connect(voter1).vote(2);
// 抛出: Error: VM Exception while processing transaction: reverted with reason string 'Vote: already voted'
// 以未授权地址投票(应被回滚)
await vote.connect(stranger).vote(1);
// 抛出: Error: VM Exception while processing transaction: reverted with reason string 'Vote: caller is not an authorized voter'
// 以 voter2 投票给不同候选人
await vote.connect(voter2).vote(3);
await vote.getCandidate(3);
// 返回: [ 3n, 'Charlie - 引入 DAO 治理', 1n ]下图以时序图的形式展示了上述交互的完整流程:
sequenceDiagram
participant U as 开发者
participant C as hardhat console
participant N as Hardhat Network
participant S as Vote.sol 合约
participant E as 链上存储
U->>C: npx hardhat console --network localhost
C->>N: 请求合约实例
N->>C: 返回 Vote 合约句柄
U->>C: vote.connect(voter1).vote(1)
C->>N: 发送交易
N->>S: 执行 vote(1)
S->>S: require(authorized)
S->>S: require(!voted)
S->>E: voted[voter1] = true
S->>E: candidates[1].voteCount++
S->>N: emit Voted
N->>C: 交易回执
C->>U: 返回成功
U->>C: vote.connect(voter1).vote(2)
C->>N: 发送交易
N->>S: 执行 vote(2)
S->>S: require(!voted) ❌ 失败回滚
N->>C: 抛出 revert
C->>U: 显示 'Vote: already voted'
这个交互过程充分验证了合约的两大核心安全属性:
- 已授权选民可以成功投票,且票数正确累加。
- 重复投票与未授权投票均被合约级
revert拦截,不会污染状态。
Hardhat Console 是这一验证过程的利器——它比写前端更快,比直接写测试用例更灵活,让开发者可以在任何阶段停下来检查合约内部状态。
本节要点
- 工程化四步法:初始化 →
npm install(Hardhat + OpenZeppelin) → 编译(hardhat compile) → 部署脚本(hardhat run) → 本地交互/测试。hardhat.config.js通过optimizer配置降低部署与执行成本。- 部署脚本不仅是「启动合约」,还应包含「预填充数据」,降低后续交互的重复工作。
npx hardhat console提供了与已部署合约实时交互的 REPL 环境,是调试与验证的最佳入口。
章末小结:3 个关键认知
- 安全不是附加功能,而是设计前提。从威胁模型出发,我们在编码之前就已经明确了「防重投、防非授权投票、防管理员作恶」三大防御目标,这些目标直接映射为
voted映射、authorizedVoters白名单与Ownable修饰符。先想「坏人怎么做」,再写「代码怎么防」,是合约开发的正确顺序。 - Checks-Effects-Interactions 是防重入的黄金铁律。
vote()函数将voted[msg.sender] = true这一状态更新放在任何可能被攻击的外部调用(如未来扩展的代币奖励)之前。这一顺序原则不仅适用于投票,也适用于所有涉及状态变更与外部调用的智能合约函数。 - Hardhat 工程化让「编译-部署-交互-测试」形成高速闭环。从
hardhat compile到hardhat run scripts/deploy.js再到hardhat console,整个流程在本地几秒内就能完成,无需等待测试网区块确认。这种快速反馈是 DApp 开发效率的核心保障。
附录:项目目录树
将本章所有代码整理后,完整的文件结构如下:
voting-dapp/
├── contracts/
│ └── Vote.sol # 投票主合约(Solidity 0.8.19)
├── scripts/
│ └── deploy.js # 部署与初始化脚本
├── test/
│ └── Vote.test.js # 单元测试(将在后续章节展开)
├── node_modules/ # 依赖包(Hardhat、OpenZeppelin 等)
├── hardhat.config.js # 编译器与网络配置
├── package.json
└── package-lock.json17.4 合约单元测试与Gas优化
17.4.1 单元测试覆盖场景
智能合约一经部署便不可修改,因此单元测试是 DApp 开发的"最后一道防线"。对于投票系统,必须验证以下核心场景:
- 成功填充选举信息
- 授权选民(选民白名单维护)
- 合法投票且正确计票
- 非法场景回滚:重复投票、非授权选民投票、非候选人投票
Hardhat 提供了与 Mocha / Chai 无缝集成的测试框架,并针对智能合约扩展了语义化的 matchers。我们的测试套件按如下流程执行:
flowchart TD
A[hardhat test] --> B[是否要顺序执行?]
B -->|是| C[beforeEach 重置快照]
C --> D[部署 Voting 合约]
D --> E[授权测试账户为选民]
E --> F[获取合约实例与 Signer]
F --> G[执行测试用例]
G --> H[expect 结果与预期
致?]
H -->|是| I["✅测试通过,下一用例"]
I -->|否| J[回滚至快照,清理状态]
J --> K[执行下一用例]
每一条测试路径都保证独立、可重复运行,通过 beforeEach 钩子每次重新部署合约,避免测试间状态污染。
17.4.2 测试核心代码
// test/Voting.test.js
const { expect } = require("chai");
describe("Voting 合约测试", function () {
let voting;
let owner, voterA, voterB, attacker;
// 每用例重置,避免状态污染
beforeEach(async () => {
[owner, voterA, voterB, attacker] = await ethers.getSigners();
const VotingFactory = await ethers.getContractFactory("Voting");
voting = await VotingFactory.deploy(["Alice", "Bob"]);
await voting.setVoterStatus(voterA.address, true);
});
it("应创建候选人", async () => {
const candidates = await voting.getCandidates();
expect(candidates).to.deep.equal(["Alice", "Bob"]);
});
it("授权选民可投票并能正确增量", async () => {
await voting.connect(voterA).vote(0);
const count = await voting.votes(0);
expect(count).to.equal(1);
});
it("非法选民尝试投票应回滚", async () => {
await expect(
voting.connect(attacker).vote(0)
).to.be.revertedWith("Not authorized voter");
});
it("重复投票应回滚", async () => {
await voting.connect(voterA).vote(0);
await expect(
voting.connect(voterA).vote(0)
).to.be.revertedWith("Already voted");
});
});关键认知:
hardhat-chai-matchers插件提供的revertedWith可精确校验回滚消息,让测试可读性大幅提升。to.equal对应基础断言,to.deep.equal用于数组/结构化数据比对。
17.4.3 Gas优化的权衡
以太坊主网的存储操作(SSTORE)极为昂贵,单次写入动辄花掉 20,000 gas。针对投票系统的优化空间:
- 状态变量打包:将独立但相关的变量尽量定义为同一结构体内部字段,Solidity 编译器会尝试紧凑排列。例如
uint8 id+uint8 voteCount可被打包进 1 个 32 字节槽位,而不是各占 1 槽。 - 事件替代存储:如果仅需事后审计、不需要合约逻辑读取,可用事件(
event Voted(address, uint8, uint))替代链上状态更新。事件写入成本约为存储操作的 1/5 至 1/8。 - 减少变量类型级别的"过度分配":使用
uint8替代uint256可节省空间,但如果该变量会频繁参与运算导致额外的类型扩展开销,反而得不偿失。权衡原则是:"仅在冷存储(写入为主、运算少)场景使用小类型"。
下表列出未优化与优化后的单笔 vote() 函数 Gas 消耗对比(基于 Hardhat Network 快照):
| 操作 | 未优化(uint256/映射) | 优化(uint8/结构体打包) | 仅事件记录 |
|---|---|---|---|
| 部署合约 | 424,801 | 398,213 | 412,005 |
| 单次投票 | 66,432 | 59,821 | 12,340 |
注意:上述对比数据在本地 Hardhat 网络测得,仅用于趋势教学,不反映主网实际价格波动。
17.4.4 要点总结
- 用
beforeEach钩子确保测试独立,避免跨测试用例状态污染。 revertedWith配合清晰错误信息,可同时验证业务逻辑与错误处理链路。- 事件是降低链上写入成本的首选方案,适用于审计类需求;若需合约内部直接读取,则仍需存储状态。
- 小类型(
uint8)的 Gas 收益仅在"冷存储"场景显著,频繁参与运算时可能得不偿失。
17.5 使用 ethers.js 与 React 构建前端
17.5.1 项目架构与数据流
前端作为用户与智能合约交互的入口,需要清晰的分层数据流。我们从组件架构开始,逐步向下连接合约层:
graph TD
A[App.jsx] --> B[WalletConnect]
A --> C[ElectionInfo]
A --> D[CandidateList]
A --> E[VoteButton]
A --> F[ResultsChart]
B -->|账号+签名者状态| A
D -->|候选人列表| E
E -->|投票请求| A
A -->|展示票数| F
核心设计原则:只读(Read)与写入(Write)分离。读取选举信息、候选人列表等操作无需钱包授权,仅需调用 provider 上的只读方法;投票等写入操作则需要用户签名,需获得 signer。通过自定义 useContract Hook,我们可以在全局高效切换这两种模式。
17.5.2 自定义 Hook:useContract
// src/hooks/useContract.js
import { useMemo } from "react";
import { Contract, JsonRpcProvider, BrowserProvider } from "ethers";
import abi from "../artifacts/contracts/Voting.sol/Voting.json";
const CONTRACT_ADDRESS = "0x..."; // 部署后替换
export function useContract(signer = null) {
return useMemo(() => {
if (!signer) return null;
// signer 模式下以写入
const contract = new Contract(CONTRACT_ADDRESS, abi.abi, signer);
return contract;
}, [signer]);
}
export function useReadContract() {
return useMemo(() => {
const provider = new JsonRpcProvider("https://rpc.sepolia.org");
return new Contract(CONTRACT_ADDRESS, abi.abi, provider);
}, []);
}Hook 职责分离:
useReadContract以无签名的 RPC 调用,不需要 MetaMask 授权即可展示基础数据;useContract将用户签名者(Signer)传递给合约实例,实现写入权限管理。
17.5.3 候选人列表组件(只读模式)
// src/components/CandidateList.jsx
import { useEffect, useState } from "react";
import { useReadContract } from "../hooks/useContract";
export default function CandidateList() {
const [candidates, setCandidates] = useState([]);
const contract = useReadContract();
useEffect(() => {
if (!contract) return;
const fetch = async () => {
const list = await contract.getCandidates();
setCandidates(list);
};
fetch();
}, [contract]);
return (
<ul className="candidate-list">
{candidates.map((name, idx) => (
<li key={idx}>
<span className="id">{idx}</span> {name}
</li>
))}
</ul>
);
}17.5.4 投票按钮组件(写入模式 + 异步状态管理)
// src/components/VoteButton.jsx
import { useState } from "react";
export default function VoteButton({ contract }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [tx, setTx] = useState(null);
const vote = async (candidateId) => {
if (!contract) return;
setError(null);
setTx(null);
setLoading(true);
try {
const txReq = await contract.vote(candidateId);
setTx(`广播中... Hash: ${txReq.hash.slice(0, 12)}...`);
const receipt = await txReq.wait();
setTx(`确认成功!区块号: ${receipt.blockNumber}`);
} catch (err) {
setError(err.code === 4001 ? "用户取消了交易" : err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={() => vote(0)} disabled={!contract || loading}>
{loading ? "投票中..." : "投票给 Alice"}
</button>
{tx && <p className="success">✅ {tx}</p>}
{error && <p className="error">❌ {error}</p>}
</div>
);
}前端交互时序如下:
sequenceDiagram
actor User
participant Button as VoteButton.jsx
participant Hook as useContract
participant MetaMask as 钱包扩展
participant EVM as 以太坊节点
User->>Button: 点击"投票"
Button->>Hook: 调用 contract.vote(id)
Hook->>MetaMask: 发起 eth_sendTransaction
MetaMask->>User: 弹出签名窗口
User->>MetaMask: 确认交易
MetaMask->>EVM: 提交已签交易
EVM-->>MetaMask: 返回 TX Hash
MetaMask-->>Hook: 返回 Transaction 对象
Hook-->>Button: 设置 tx Hash 状态
Button-->>User: 显示 "广播中..."
EVM->>EVM: 挖矿确认(等待...)
EVM-->>MetaMask: 确认回调
MetaMask-->>Hook: 交易回执(receipt)
Hook-->>Button: 设置 confirmed 状态
Button-->>User: 显示 "✅ 确认成功"
异步三态原则:所有涉及交易的 UI 必须显式管理
loading / error / success三种状态。没有反馈的按钮会让用户误以为操作成功或失败,导致重复触发交易(重复投票)。
17.5.5 要点总结
- 前端与合约交互应按 只读 / 写入 拆分为两类 Hook,避免在不需要时弹出钱包授权。
ethers.Contract创建开销极小,但仍应在useMemo中缓存,避免依赖项不变时重复实例化。- 所有与交易相关的按钮必须显示三个状态:加载中、成功反馈、错误提示。
- Mermaid 组件树图可将复杂的前端层级以可视化的方式呈现,便于团队协作与架构评审。
17.6 钱包连接与签名交互
17.6.1 检测与连接 MetaMask
钱包插件通过浏览器注入的 window.ethereum 对象提供接口。主流前端第一步:检测对象是否存在,若缺失则给出明确的安装引导。
// src/components/WalletConnect.jsx
import { useState } from "react";
import { BrowserProvider } from "ethers";
import { useContract } from "../hooks/useContract";
export default function WalletConnect({ onConnect }) {
const [status, setStatus] = useState("未连接");
const [address, setAddress] = useState(null);
const connectWallet = async () => {
if (typeof window.ethereum === "undefined") {
setStatus("未检测到兼容钱包,请安装 MetaMask");
return;
}
try {
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
setAddress(accounts[0]);
setStatus("已连接");
onConnect(signer);
} catch (err) {
if (err.code === 4001) {
setStatus("用户拒绝了账户授权");
} else {
setStatus("连接失败: " + err.message);
}
}
};
return (
<div className="wallet-connect">
<p>状态: <b>{status}</b></p>
{address && <p>地址: {address.slice(0, 10)}...{address.slice(-6)}</p>}
<button onClick={connectWallet}>连接钱包</button>
</div>
);
}17.6.2 网络切换与链 ID 校验
测试网(Sepolia)与主网的链 ID 不同,前端需确保用户连接到了目标网络。不匹配时主动调用 wallet_switchEthereumChain 切换。
// 网络配置映射
const SUPPORTED_CHAIN = 0xaa36a7; // Sepolia 测试网
const SEPOLIA_RPC = "https://rpc.sepolia.org";
async function checkAndSwitchNetwork() {
const chainId = await window.ethereum.request({ method: "eth_chainId" });
if (Number(chainId) !== SUPPORTED_CHAIN) {
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: `0x${SUPPORTED_CHAIN.toString(16)}` }],
});
} catch (err) {
if (err.code === 4902) {
// 用户未添加目标网络,请求添加
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: `0x${SUPPORTED_CHAIN.toString(16)}`,
chainName: "Sepolia 测试网",
rpcUrls: [SEPOLIA_RPC],
}],
});
} else {
throw err;
}
}
}
}钱包连接的状态机可用以下状态图描述:
stateDiagram
[*] --> 未连接
未连接 --> 已连接: 检测到钱包
已连接 --> 网络错误: 链 ID 不匹配
网络错误 --> 已连接: 切换网络成功
已连接 --> 交易进行中: 发送交易
交易进行中 --> 已连接: 交易结果返回
已连接 --> 未连接: 钱包断开/锁定
未连接 --> [*]
17.6.3 交易发送与 Gas 估算
// 带 Gas Limit 估算的投票交易发送
const sendVoteTransaction = async (contract, candidateId) => {
const txReq = await contract.vote.populateTransaction(candidateId);
const gasEstimate = await contract.runner.estimateGas(txReq);
const gasLimit = (gasEstimate * 120n) / 100n; // 增加 20% 安全余量
const tx = await contract.vote(candidateId, { gasLimit });
return tx.wait();
};Gas 安全策略:直接预估值发送存在竞争条件下高失败风险。增加 10%–20% 余量可显著降低交易被打回(revert)概率,同时避免过度浪费。若设置过大,多余 gas 不会被消耗,仅影响最大上限。
17.6.4 离线签名(高阶选学)
去中心化应用的某些场景(如链下数据登记、轻量级身份认证)不需要交易上链,只需证明某地址拥有对应私钥即可。
// 投票前的链下身份验证(可选)
async function signAuthorization(signer, account, candidateId) {
const message = `I authorize my address {account} to vote for candidate #{candidateId}`;
const signature = await signer.signMessage(message);
return { account, message, signature };
// 后端验证:ethers.utils.verifyMessage(message, signature) === account
}链下签名零 Gas、几乎即时生效,适用于需要高速确认的业务。投票 DApp 主流程不在链下完成,但可以引入"链下预授权 + 可信后端代投"模式降低前端摩擦。
17.6.5 要点总结
window.ethereum的检测是必做步骤,未安装钱包时应提供清晰的安装引导而非报错。eth_requestAccounts弹出授权窗口时,务必捕获 4001 错误码,区分"用户拒绝"与"系统错误",提升交互体验。- 链 ID 校验 +
wallet_switchEthereumChain切换是用户 onboarding 的"防坑"关键,避免用户误在主网支付真实 Gas。 - 带
estimateGas+ 20% 安全余量的发送模式,能显著降低交易失败率。 - 离线签名是补全前端工具箱的高阶技能,在认证与快速确认场景下尤其实用。
总结:三阶段模型的闭环
本章将投票 DApp 从合约"黑盒"延伸为可交互的完整应用:
- 17.4 单元测试 建立了后端安全底线,Gas 优化让我们理解链上资源的真实成本。
- 17.5 前端架构 通过 React 组件树与自定义 Hook,将复杂合约接口转化为清晰、可复用的 UI 层。
- 17.6 钱包交互 打通了用户与区块链之间的"最后一公里",从验权、网络切换到安全交易发送,确保每一次投票都可靠可追溯。
这三个环节共同构成了一条从代码到用户的完整链路。下一章,我们将进一步拓展:监听合约事件以实时更新投票结果图表,并最终将 DApp 部署至公共测试网。
17.7 监听合约事件与实时更新UI
17.7.1 为什么需要链上事件?
智能合约运行在EVM中,由于区块链的封闭性,合约无法主动「推送」消息给前端。Solana的做法是让前端轮询账户状态,而以太坊提供了更优雅的机制——事件(Event)。
事件通过LOG0-LOG4操作码写入交易收据(Receipt)的日志字段。事件的特点是:
- 不可合约内读取:同一EVM内的其他合约无法消费你发出的事件
- 链下永久可查:任何人通过节点RPC可以检索历史事件
- Gas成本低:写入日志比写入存储(SSTORE)便宜得多
在投票合约中,我们定义了Voted事件:
event Voted(address indexed voter, uint indexed candidateId, uint timestamp);这里的indexed关键字至关重要:索引参数(最多3个)会被放入topic[1]-topic[3]中,支持前端按主题过滤。非索引参数(如timestamp)则放在data字段。
17.7.2 ethers.js 事件监听实战
前端使用ethers.js监听Voted事件的核心代码:
// hooks/useEventListener.js
import { useEffect, useRef } from 'react';
import { ethers } from 'ethers';
export function useEventListener(contract, eventName, callback) {
const callbackRef = useRef(callback);
callbackRef.current = callback; // 保证闭包中的callback始终最新
useEffect(() => {
if (!contract) return;
const handler = (...args) => {
const event = args[args.length - 1]; // 最后一个参数是Event对象
callbackRef.current(args.slice(0, -1), event);
};
contract.on(eventName, handler);
return () => {
contract.off(eventName, handler); // 组件卸载时必须清理!
};
}, [contract, eventName]);
}⚠️ 常见陷阱:忘记在
useEffect返回值中清理监听会导致内存泄漏。如果组件频繁挂载/卸载而不清理,最终会堆积大量重复监听,造成性能下降甚至事件重复响应。
在投票组件中使用:
useEventListener(contract, 'Voted', (args, event) => {
const [voter, candidateId, timestamp] = args;
setVotes(prev => ({
...prev,
[candidateId.toString()]: (prev[candidateId.toString()] || 0) + 1,
}));
});17.7.3 三种状态同步策略对比
| 策略 | 延迟 | Gas开销 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| 轮询 | 高(~12s/次) | 无(只读RPC不消耗Gas) | 低 | 对实时性要求低的展示页 |
| 事件驱动 | 低(出块后即时) | 无 | 中 | 实时DApp交互页 |
| The Graph子图 | 中(索引延迟数秒) | 无 | 高 | 复杂历史数据查询 |
事件驱动是投票DApp的首选方案:用户投票后,事件在同一个区块被发出,前端几乎实时收到通知。但页面刷新后,初始数据仍需通过合约只读调用获取:
// 页面加载时拉取最新状态
useEffect(() => {
if (!contract) return;
const loadCandidates = async () => {
const count = await contract.getCandidateCount();
// ... 遍历并拉取每个候选人数据
};
loadCandidates();
// 后续更新由事件驱动
}, [contract]);17.7.4 结果可视化组件
使用 recharts 库展示投票结果:
import { BarChart, Bar, XAxis, YAxis, Tooltip } from 'recharts';
function ResultsChart({ candidates }) {
const data = candidates.map(c => ({
name: c.name,
得票数: c.voteCount,
}));
return (
<BarChart width={600} height={300} data={data}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="得票数" fill="#8884d8" />
</BarChart>
);
}当Voted事件触发时,React状态更新会立即反映到柱状图上,实现实时票数增长动画。
17.7.5 Mermaid图表:事件驱动流程
sequenceDiagram
participant User as 投票者
participant Frontend as React前端
participant Contract as 投票合约(EVM)
participant Event as 链上事件日志
User->>Frontend: 点击投票按钮
Frontend->>Frontend: 乐观更新UI(先+1)
Frontend->>Contract: sendTransaction(vote(1))
Contract-->>Event: 触发Voted(voter, 1, timestamp)
Note over Contract,Event: LOG1操作码写入收据
Event->>Frontend: contract.on('Voted', ...)
Frontend->>Frontend: 核对与乐观更新一致✓
Frontend->>User: 实时刷新柱状图
Note over Frontend: 若交易失败,回滚乐观更新
17.7.6 本节要点
- 事件是连接链上状态与前端UI的核心桥梁,比轮询更高效
indexed参数支持前端按主题过滤,最多3个索引参数- 前端必须管理事件监听的生命周期,防止内存泄漏
- 乐观更新提升交互体验,但需做好交易回滚时的状态恢复
17.8 部署至测试网与合约验证
17.8.1 测试网选型与环境准备
2025年,以太坊的主要测试网是Sepolia。它取代了已弃用的Goerli和Ropsten,是合约开发的标准测试环境。
准备工作:
- 获取RPC端点:注册Infura或Alchemy,创建一个Sepolia项目,获取HTTPS端点URL
- 获取测试ETH:访问 Sepolia Faucet(如Alchemy Faucet),输入你的测试网地址领取测试ETH
- 管理私钥:创建
.env文件,配置环境变量
# .env — 禁止提交到Git!
PRIVATE_KEY=0x你的测试网私钥(不含0x前缀)
INFURA_API_KEY=你的Infura项目ID
ETHERSCAN_API_KEY=你的Etherscan API Key17.8.2 Hardhat网络配置
在hardhat.config.ts中添加Sepolia网络配置:
import { HardhatUserConfig } from 'hardhat/config';
import '@nomicfoundation/hardhat-toolbox';
import * as dotenv from 'dotenv';
dotenv.config();
const config: HardhatUserConfig = {
solidity: '0.8.20',
networks: {
sepolia: {
url: `https://sepolia.infura.io/v3/${process.env.INFURA_API_KEY}`,
accounts: [process.env.PRIVATE_KEY!],
// EIP-1559: 交易类型2的参数
maxFeePerGas: 100_000_000_000n, // 100 gwei
maxPriorityFeePerGas: 5_000_000_000n, // 5 gwei
},
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};
export default config;17.8.3 部署脚本
// scripts/deploy.js
const hre = require('hardhat');
async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log('部署账户:', deployer.address);
const candidates = ['Alice', 'Bob', 'Charlie'];
const Election = await hre.ethers.getContractFactory('Election');
const election = await Election.deploy(candidates);
await election.waitForDeployment();
const addr = await election.getAddress();
console.log('选举合约已部署至:', addr);
console.log('候选人:', candidates.join(', '));
// 输出验证命令
console.log('\n运行验证命令:');
console.log(`npx hardhat verify --network sepolia {candidates.join('","')}"`);
}
main().catch(console.error);执行部署:
npx hardhat run scripts/deploy.js --network sepolia⚠️ 常见错误:如果提示
insufficient funds,说明账户中测试ETH不足,需要去Faucet补充。
17.8.4 合约验证
部署完成后,在Etherscan上验证合约源码:
npx hardhat verify --network sepolia <合约地址> "Alice" "Bob" "Charlie"验证成功后,用户可以在Etherscan上看到:
- 完整的合约源码(Solidity原文件或扁平化后的版本)
- 自动生成的Read Contract和Write Contract交互界面
- ABI和字节码的公开记录
这对增强DApp透明度和用户信任至关重要。
17.8.5 前端部署到Vercel
前端集成验证后的合约地址,然后部署到Vercel:
- 将前端代码推送到GitHub仓库
- 在Vercel控制台导入该仓库
- 设置构建命令:
npm run build - 添加环境变量(无需私钥):
VITE_CONTRACT_ADDRESS:验证后的合约地址VITE_RPC_URL:Alchemy/Infura的Sepolia RPC端点
- 部署完成,获得可公开访问的URL
flowchart LR
A[编写合约] -->|本地测试| B[Hardhat测试通过]
B --> C[配置Sepolia网络]
C --> D[获取测试ETH]
D --> E[部署到Sepolia]
E --> F[Etherscan验证]
F --> G[更新前端合约地址]
G --> H[Vercel部署前端]
H --> I[全栈DApp上线]
17.8.6 本节要点
- Sepolia是当前以太坊主流测试网,替代已弃用的Goerli
.env文件管理私钥和API Key,必须加入.gitignore- 合约验证提高透明度和用户信任,是高质量DApp的必要步骤
- 前端部署只需合约地址和RPC端点,无需私钥
17.9 完整项目复盘
17.9.1 项目目录结构
一个完整的投票DApp项目结构如下:
voting-dapp/
├── contracts/ # Solidity源代码
│ ├── Election.sol # 核心投票合约
│ └── VoterRegistry.sol # 选民注册合约
├── test/
│ └── election.test.js # 合约单元测试
├── scripts/
│ ├── deploy.js # 部署脚本
│ └── verify.js # 验证脚本
├── frontend/
│ ├── src/
│ │ ├── App.jsx # 主应用入口
│ │ ├── components/
│ │ │ ├── WalletConnect.jsx # 钱包连接
│ │ │ ├── ElectionInfo.jsx # 选举信息展示
│ │ │ ├── CandidateList.jsx # 候选人列表
│ │ │ ├── VoteButton.jsx # 投票按钮
│ │ │ └── ResultsChart.jsx # 结果图表
│ │ ├── hooks/
│ │ │ ├── useContract.js # 合约交互Hook
│ │ │ └── useEventListener.js# 事件监听Hook
│ │ └── utils/
│ │ └── constants.js # 合约地址/ABI常量
│ ├── package.json
│ └── vite.config.js
├── hardhat.config.ts
├── .env
└── README.md17.9.2 关键架构决策回顾
为什么使用工厂模式设计Election合约?
工厂模式允许一次部署创建一个「选举工厂」,后续可以通过工厂合约的方法创建多轮独立的选举。这比在合约中硬编码候选人列表更灵活,也便于扩展。
为什么要分离VoterRegistry合约?
将选民注册逻辑从Election合约中分离出来,使得投票权限管理可以与投票本身解耦。例如,你可以用同一个VoterRegistry管理多轮选举的选民资格,或者在未来将注册逻辑替换为ERC-20余额快照。
为什么选择事件驱动而非轮询?
投票DApp对实时性有一定要求:用户投票后期待立即看到票数变化。轮询每12秒(一个区块时间)拉取一次数据,延迟感明显。事件驱动在区块被挖出的瞬间就将新数据推送到前端,体验更接近Web2应用。
前端状态管理选型思考
对于投票DApp这种中等复杂度的场景,React Context + useState/useReducer完全足够。引入Redux会增加不必要的样板代码。如果未来需要管理更复杂的状态(如多轮选举、用户Session、链上通知),可以考虑Zustand或Jotai这类轻量状态管理库。
页面刷新后的状态恢复
所有状态最终由链上数据驱动。当用户刷新页面时:
- 前端重新连接钱包(MetaMask自动恢复session)
- 合约只读方法拉取最新数据(候选人列表、票数)
- 事件监听重新启动,接收后续更新
这一模式称为「Source of Truth on-chain」——前端只是链上状态的缓存。
17.9.3 开发中的常见陷阱
| 陷阱 | 现象 | 解决方案 |
|---|---|---|
| MetaMask网络切换 | 调用交易时网络不匹配 | 监听chainChanged事件,刷新页面或提示切换 |
| ABI版本冲突 | 前端用旧ABI调用新版合约 | 部署后从artifacts/复制最新ABI |
| 事件监听内存泄漏 | 多次调用合约方法,UI性能下降 | useEffect返回值中调用contract.off() |
| Gas估算失败 | 交易长时间pending | 手动设置gasLimit参数 |
| 异步错误吞没 | 交易失败但无提示 | 使用.catch()或在try/catch中捕获并展示 |
| 本地与测试网地址混淆 | 前端连接本地合约地址而非测试网 | 使用.env管理网络相关配置 |
17.9.4 作业与延伸方向
如果你已完成本节内容,以下是值得继续探索的方向:
- 添加时间锁
- 在Election合约中添加
votingDeadline变量 - 投票函数增加时间检查:
require(block.timestamp < votingDeadline) - 前端显示投票倒计时
- 加权投票(基于ERC-20余额)
- 引入ERC-20代币地址作为投票权重依据
- 投票时读取调用者的代币余额作为票数权重
- 需注意防止闪电贷操纵快照余额
- 链上委托投票
- 实现
delegate(address to)函数 - 将投票权委托给他人,类似Compound的治理模型
- DAO风格治理
- 引入多签国库(Gnosis Safe)
- 实现提案创建 → 投票 → 执行的三阶段流程
17.9.5 完整项目架构图
flowchart TB
subgraph 合约层
EC[Election 合约] --- VR[VoterRegistry 合约]
end
subgraph 区块链基础设施
RPC[RPC节点/Infura] --- ES[Etherscan]
end
subgraph 前端层
WC[WalletConnect 组件]
EI[ElectionInfo 组件]
CL[CandidateList 组件]
VB[VoteButton 组件]
RC[ResultsChart 组件]
HL[useEventListener Hook]
end
subgraph 用户
V[投票者]
AD[管理员]
end
V <-->|MetaMask| WC
AD -->|部署/注册| EC
EC <-->|合约事件| HL
HL --> EI
HL --> RC
VB -->|sendTransaction| EC
RC -->|只读调用| EC
EC -->|已验证| ES
V --> VB
AD --> VR
本章要点
- 全栈DApp = 合约 + 工具链 + 前端 + 部署:四条腿缺一不可,每条腿都是一套独立的工程知识体系
- 事件驱动是DApp实时交互的基础:理解EVM日志机制、ethers.js事件API、前端生命周期管理,是区分Web2前端开发者和Web3全栈开发者的标志性能力
- 测试网部署是将DApp推向真实的必经之路:本地开发环境与测试网环境的差异(RPC限速、出块时间、Gas价格)必须在早期暴露
- 项目复盘比项目本身更重要:回顾架构决策、总结开发陷阱、规划延伸方向,决定了你是做完一个项目还是真正理解了一个项目
评论
0评论加载中…